질문 2: Lock 기법의 종류와 각각을 사용하는 이유, 특히 세마포어와 mutex의 차이. (5~6절에 thread-safe의 조건인 원자성, 그리고 “sleep으로 context switching을 유도하는가”를 추가.) 핵심은 “락은 공유 자원의 경쟁 조건(race condition)을 막는 도구이고, 상황(임계 구역 길이·읽기/쓰기 비율·개수 제한·프로세스 범위)에 따라 다른 락을 고른다”이다. 세마포어와 뮤텍스는 소유권(ownership) 과 목적(상호 배제 vs 신호/개수) 으로 구분한다.
1. 왜 Lock이 필요한가
여러 스레드/프로세스가 같은 데이터를 동시에 읽고 쓰면 결과가 실행 순서에 따라 달라진다(race condition).
count++조차 load → add → store 세 단계라 중간에 다른 흐름이 끼어들 수 있다.
락은 임계 구역(critical section) 을 한 번에 하나의 흐름만(또는 정해진 수만) 통과하게 만들어 원자성과 가시성을 보장한다.
2. Lock 종류와 사용 이유
종류
동작
사용하는 이유 / 적합한 상황
주의
Mutex (pthread_mutex_t)
한 번에 하나만 진입. 못 잡으면 잠든다(sleep/wake)
일반적인 공유 데이터 보호. 임계 구역이 길거나 블로킹 작업이 있어도 CPU를 낭비하지 않음
락을 건 스레드만 해제. 데드락 주의
Spinlock (pthread_spinlock_t)
못 잡으면 루프를 돌며 계속 확인(busy-wait)
임계 구역이 아주 짧고, 문맥 전환 비용이 더 큰 경우. 커널/인터럽트 핸들러처럼 잠들 수 없는 곳
단일 코어에서는 낭비. 락 보유 중 선점/블로킹 금지
Reader-Writer Lock (pthread_rwlock_t)
읽기는 여러 개 동시, 쓰기는 단독
읽기가 압도적으로 많은 테이블·설정
락 오버헤드 커서 구간이 짧으면 mutex보다 느릴 수 있음. 쓰기 기아(writer starvation)
세마포어 (sem_t)
카운터 기반: wait(감소, 0이면 대기) / post(증가)
동시 접근 N개 제한(자원 풀), 스레드/프로세스 간 신호 전달(생산자-소비자)
소유권 없음 → 오남용 시 데드락 추적 어려움
Condition Variable (pthread_cond_t)
“어떤 조건이 참이 될 때까지” 대기
mutex와 짝으로 큐 비어있음/가득 참 같은 조건 대기
반드시 while로 재검사(spurious wakeup)
Atomic 연산 / CAS
CPU 원자 명령 (__atomic_*, __sync_*, C11 stdatomic.h)
카운터·플래그·포인터 교체를 락 없이. 가장 빠름
단순한 값에만. 복합 갱신은 불가
Seqlock
읽기는 락 없이 하고 읽는 동안 쓰기가 있었으면 재시도
쓰기 드물고 데이터가 작은 경우(타임스탬프 등)
포인터 따라가기 같은 읽기에는 위험
RCU
읽기는 비용 거의 0, 쓰기는 복사 후 교체 + 유예 기간 후 해제
읽기 매우 많은 자료구조(리눅스 커널)
구현 복잡
파일 락 (flock, fcntl)
파일(fd)에 잠금
프로세스 간 단순 상호 배제, 프로세스 사망 시 자동 해제
용도 한정
프로세스 공유 락
공유메모리 안 mutex(PTHREAD_PROCESS_SHARED), System V/POSIX 세마포어
프로세스 간 동기화
사망 대비 robust mutex / SEM_UNDO
분산 락
Redis, ZooKeeper, etcd
여러 서버 간
만료·장애·펜싱 토큰
선택 가이드
값 하나(카운터·플래그) → atomic
일반 공유 데이터 → mutex
임계 구역이 극히 짧고 잠들 수 없는 곳 → spinlock
읽기 위주 → rwlock (더 극단적이면 RCU/seqlock)
자원 개수 제한 / 신호 전달 → 세마포어 (조건 대기는 condvar)
프로세스 간 → 프로세스 공유 mutex 또는 세마포어
락이 필요 없는 구조가 먼저다. 스레드별 데이터 분리, 불변 데이터, 한 스레드가 소유하고 메시지로 요청받는 구조는 락 경합 자체를 없앤다.
3. 세마포어 vs 뮤텍스 (면접 단골)
구분
뮤텍스(Mutex)
세마포어(Semaphore)
목적
상호 배제 (한 번에 하나만 임계 구역 진입)
개수 제한 또는 신호 전달(동기화 순서)
값
잠금/해제 2상태
정수 카운터 (0..N)
소유권
있음 — 락을 건 스레드만 해제할 수 있음
없음 — 아무 스레드나 post 가능
대기 방식
못 잡으면 sleep
카운터가 0이면 sleep
해제 주체
잠근 스레드
다른 스레드도 가능 (신호 용도)
우선순위 역전 대응
우선순위 상속(Priority Inheritance) 지원 가능
일반적으로 없음
재귀
재귀 뮤텍스 가능
해당 없음
대표 사용
공유 변수·자료구조 보호
자원 풀(DB 커넥션 N개), 생산자-소비자, 스레드 시작/종료 신호
프로세스 간
기본은 스레드용, PTHREAD_PROCESS_SHARED 필요
System V/POSIX named는 프로세스 간 기본
핵심 차이를 한 문장으로
뮤텍스는 “열쇠”: 열쇠를 가진 한 명만 들어가고, 잠근 사람이 직접 풀어야 한다.
세마포어는 “주차 가능 대수 표지판”: 남은 자리가 있으면 들어가고, 다른 사람이 자리를 비워도(post) 된다.
예시
/* 뮤텍스: 공유 카운터 보호 */pthread_mutex_lock(&m);count++;pthread_mutex_unlock(&m); /* 잠근 스레드가 해제 *//* 세마포어: 최대 3개 동시 접근 */sem_t sem; sem_init(&sem, 0, 3);sem_wait(&sem); /* 자리 확보 (남은 수 -1, 0이면 대기) */use_resource();sem_post(&sem); /* 자리 반납 (남은 수 +1) *//* 세마포어: 신호 전달 (초기값 0) *//* 소비자 */ sem_wait(&items); /* 생산자가 post 할 때까지 대기 *//* 생산자 */ push(item); sem_post(&items);
”Binary 세마포어 = 뮤텍스?” 에 대한 답
카운터가 0/1이라 동작은 비슷해 보이지만 같지 않다.
소유권: 바이너리 세마포어는 다른 스레드가 post로 풀 수 있다(실수로 풀리거나 의도적 신호). 뮤텍스는 소유자만 해제.
우선순위 상속: 뮤텍스는 우선순위 역전(Priority Inversion)을 완화하는 프로토콜을 가질 수 있다.
의도 표현: 뮤텍스는 “보호”, 세마포어는 “신호/개수”라는 의도가 코드에 드러난다.
따라서 상호 배제는 뮤텍스, 신호·개수 제한은 세마포어로 구분해서 쓴다.
4. 데드락(Deadlock)과 주의점
데드락 4조건: 상호 배제, 점유와 대기, 비선점, 순환 대기 — 하나만 깨면 예방된다.
실무 대응: 락 획득 순서 고정, pthread_mutex_trylock/timedlock, 락 범위 최소화, 락 안에서 외부 호출·블로킹 금지.
우선순위 역전: 낮은 우선순위가 락을 쥔 채 중간 우선순위에 밀려 높은 우선순위가 대기 → 우선순위 상속 뮤텍스(PTHREAD_PRIO_INHERIT).
조건변수는 while로: pthread_cond_wait는 깨어난 뒤 조건을 다시 검사해야 한다(spurious wakeup).
pthread_mutex_lock(&m);while (queue_empty()) /* if 가 아니라 while */ pthread_cond_wait(&cv, &m); /* 락을 풀고 대기, 깨면 다시 락 획득 */item = pop();pthread_mutex_unlock(&m);
락 안에서 시그널 핸들러 금지: 핸들러는 async-signal-safe 함수만 호출 가능(pthread_mutex_lock은 불가).
5. Thread-safe의 조건 — 원자성 · 가시성 · 순서
Thread-safe: 여러 스레드가 동시에 호출해도 실행 순서(스케줄링)와 상관없이 항상 올바른 결과를 내는 것. 핵심은 원자성(Atomicity) 이고, 여기에 가시성과 순서 보장이 더해진다.
요소
의미
깨지면
보장 수단
원자성
연산이 중간 상태가 관찰되지 않는 하나의 단위로 실행됨
갱신 손실(lost update), 쪼개진 값(torn read)
mutex/rwlock, atomic 연산(CAS, fetch_add)
가시성
한 스레드가 쓴 값이 다른 스레드에게 보임
오래된 값(stale)을 계속 읽음 (CPU 캐시·레지스터 캐싱)
락 획득/해제, atomic(acquire/release), 메모리 배리어
순서(재배치 금지)
컴파일러·CPU가 명령을 재배치해도 프로그램 의미가 유지됨
초기화 전 객체가 다른 스레드에 노출(unsafe publication)
락, atomic 메모리 순서(memory_order_*), 배리어
원자성이 왜 문제인가 — count++
count++는 한 줄이지만 CPU에서는 세 단계다.
(1) LOAD : 메모리 count → 레지스터
(2) ADD : 레지스터 + 1
(3) STORE : 레지스터 → 메모리 count
스레드 A: LOAD(5) ────────────── ADD(6) ─ STORE(6)
스레드 B: LOAD(5) ─ ADD(6) ─ STORE(6) ← 둘 다 +1 했는데 결과 6 (하나 손실)
선점형 스케줄러는 어느 명령 사이에서든 스레드를 교체할 수 있다. 그래서 위 인터리빙이 언제든 가능하다.
복합 연산도 원자적이지 않다: if (x == 0) x = 1;(check-then-act), read-modify-write, 여러 변수의 불변식(예: 계좌 A에서 빼고 B에 더하기).
/* 안전한 방법 1) atomic */#include <stdatomic.h>atomic_int count;atomic_fetch_add(&count, 1); /* 한 번에 읽고-더하고-쓰기 *//* 안전한 방법 2) mutex : 여러 변수/복합 연산에도 사용 가능 */pthread_mutex_lock(&m);count++;pthread_mutex_unlock(&m);
volatile은 원자성을 주지 않는다.volatile int count; count++도 여전히 3단계다.
sleep은 호출한 스레드를 재우고 CPU를 양보하므로 결과적으로 context switch가 일어나는 것은 맞다. 하지만 “context switching을 유도해서 동기화 문제를 해결”하는 용도로 쓰면 안 된다.sleep은 동기화 수단이 아니라 시간 지연 수단이며, 타이밍에 기대는 코드는 환경에 따라 깨진다.
내부 동작
sleep() / nanosleep()
→ 스레드 상태를 RUNNING → SLEEPING(대기 큐)로 바꾸고 타이머 등록
→ 스케줄러가 다른 실행 가능 스레드를 선택 → context switch
→ 타이머 만료 시 RUNNABLE로 복귀 (즉시 실행되는 것은 아님, 스케줄링 대기)
지정한 시간 이상 잔다(정확히가 아님). 시그널이 오면 일찍 깨고 EINTR(남은 시간은 nanosleep의 rem)이 반환된다.
락을 쥐고 있으면 sleep 중에도 락은 풀리지 않는다. (조건변수 pthread_cond_wait는 락을 풀고 대기한다는 점이 다르다.)
context switch는 “유도”하지 않아도 이미 언제든 일어난다
선점형 OS에서는 타임 슬라이스 만료, 인터럽트, 블로킹 I/O, 더 높은 우선순위 스레드 등장 시 아무 때나 스레드가 교체된다.
따라서 올바른 멀티스레드 코드는 “어느 명령 사이에서 전환돼도 정확” 해야 한다. 전환 시점을 sleep으로 제어해 맞추는 접근은 이 전제를 어긴다.
sleep을 쓰는 경우 vs 쓰면 안 되는 경우
구분
예
평가
✅ 주기 작업 / 폴링 간격
1초마다 상태 확인, 백오프 재시도(지수 백오프+지터), rate limit
정당한 시간 지연
✅ 백그라운드 스레드의 대기
주기적 flush, 하트비트
정당 (단, 종료 신호에 반응하도록 timedwait/poll 권장)
✅ 경쟁 조건 재현용 테스트
읽기와 쓰기 사이에 usleep을 넣어 갱신 손실을 일부러 드러냄
재현·교육용만. 수정 수단이 아님
❌ 순서 보장
”sleep(1) 했으니 상대 스레드가 초기화를 끝냈겠지”
부하·코어 수·머신에 따라 깨짐(flaky), 필요 이상으로 느림
❌ 락 대신 사용
sleep으로 서로 다른 시간에 실행되게 해 충돌 회피
보장 없음, 레이스 그대로
❌ 락을 쥔 채 sleep
임계 구역이 길어져 다른 스레드 전부 대기, 데드락/지연
락 밖에서 대기
❌ busy-wait 완화용 sleep(0)/짧은 sleep
CPU 낭비를 줄이려는 시도
지연·낭비가 섞임 → 조건변수/세마포어로
❌ 종료 대기를 sleep으로
sleep(5) 후 스레드가 끝났겠지 하고 진행
pthread_join, 완료 신호 사용
경쟁 조건 재현 예 (sleep은 문제를 “드러내는” 도구)
/* sleep 으로 읽기-쓰기 사이를 벌려 갱신 손실을 확실히 재현 */void *worker(void *arg) { int tmp = shared; /* READ */ usleep(1000); /* ← context switch 유도: 다른 스레드가 끼어들 기회 */ shared = tmp + 1; /* WRITE : 그 사이 다른 스레드가 올린 값을 덮어씀 */ return NULL;}/* 2개 스레드가 실행 → 기대 2, 실제 1 */
이렇게 레이스 윈도우를 넓혀 재현하는 데는 유용하지만, 고치려면 락/atomic을 써야 한다. sleep을 더 넣거나 줄여서 “안 터지게” 만드는 것은 해결이 아니라 숨기는 것이다.
레이스 탐지는 sleep 대신 ThreadSanitizer(-fsanitize=thread), 스트레스 테스트(스레드 수·반복 증가), 지연 주입 도구를 쓴다.
목적별 올바른 대안
하고 싶은 것
sleep 대신
다른 스레드가 끝나길 기다림
pthread_join
”준비됐다/조건이 만족됐다”를 기다림
조건변수(pthread_cond_wait/timedwait), 세마포어
여러 스레드 동시에 출발
barrier (pthread_barrier_wait)
초기화를 한 번만
pthread_once
다른 스레드에게 CPU 양보
sched_yield() (스케줄러가 양보를 보장하지는 않음, 힌트 수준)
I/O 대기 + 타임아웃
poll/epoll/select의 타임아웃 (종료 신호에도 즉시 반응)
일정 시간 후 실행
timerfd, 타이머 스레드, clock_nanosleep(TIMER_ABSTIME)
정밀한 지연
usleep(obsolete) 대신 nanosleep / clock_nanosleep
스핀락과의 관계 (context switch 비용 관점)
context switch는 레지스터·스택 포인터 저장/복원, 스케줄러 실행, 캐시·TLB 오염이 따라 수 마이크로초 수준의 비용이 든다.
그래서 임계 구역이 극히 짧을 때는 잠들지 않고 계속 확인하는 스핀락이 유리하고, 길면 뮤텍스(잠들면서 CPU 양보)가 낫다. sleep/뮤텍스 대기는 “CPU를 양보하는 대신 context switch 비용을 치르는” 선택이다.
스핀 루프 안에서 sleep을 섞는 하이브리드(스핀 몇 번 → 양보/sleep)는 락 구현 내부(adaptive mutex)에서 쓰는 기법이지, 응용 코드에서 직접 흉내 낼 일은 드물다.
7. 꼬리 질문
질문
답변 요지
뮤텍스와 스핀락 중 무엇을 쓰나?
기본은 뮤텍스. 임계 구역이 마이크로초 이하로 짧고 멀티코어이며 잠들 수 없을 때만 스핀락.
락을 쥔 채 프로세스가 죽으면?
다른 프로세스가 영원히 대기 → robust mutex(EOWNERDEAD 처리) 또는 SEM_UNDO.
atomic이면 락이 필요 없나?
단일 값 갱신은 충분. 여러 변수의 일관성(불변식)이 필요하면 락이 필요.
락 경합이 심하면?
락 구간 축소, 락 분할(샤딩), rwlock/RCU, 스레드별 전용 데이터/큐, 락 프리 구조.
volatile이 동기화 수단인가?
아니다. 컴파일러 최적화를 막을 뿐 원자성·메모리 순서를 보장하지 않는다. 동기화는 atomic/락으로.
재귀 뮤텍스를 쓰는 이유와 단점?
같은 스레드가 재진입해야 할 때 필요. 하지만 설계가 얽혔다는 신호일 수 있고 약간 느리다.
thread-safe의 핵심은?
원자성(중간 상태가 안 보이는 단위 실행)이며, 여기에 가시성(다른 스레드에 보임)과 순서(재배치로 깨지지 않음)가 필요하다. 락과 atomic이 셋을 함께 제공한다.
sleep으로 스레드 실행 순서를 맞추면 왜 안 되나?
타이밍에 의존해 부하·CPU 코어 수·스케줄링에 따라 깨지고(flaky), 불필요하게 느리다. 순서는 join/조건변수/세마포어/barrier 같은 명시적 동기화로 보장한다.
sleep이 일으키는 context switch는 문제 없나?
호출 스레드가 재워지고 CPU가 양보되므로 전환은 일어나지만, 정확성은 전환 시점과 무관해야 한다. 선점형 OS에서는 sleep이 없어도 전환은 언제든 일어난다.
sleep 중에 락은 풀리나?
풀리지 않는다. 락을 쥔 채 sleep하면 다른 스레드가 모두 대기한다. 대기하면서 락을 풀려면 pthread_cond_wait를 쓴다.
sched_yield는 sleep과 뭐가 다른가?
sleep은 시간만큼 블록(타이머), sched_yield는 시간 지정 없이 실행 가능 상태로 남은 채 다른 스레드에게 양보를 요청(보장 없음, 대기할 이유가 없으면 바로 복귀).
레이스를 sleep으로 재현하는 건 괜찮나?
재현·교육용으로 읽기-쓰기 사이를 벌려 문제를 드러내는 데는 유용하다. 단 수정은 락/atomic으로 하고, 탐지는 TSan·스트레스 테스트로 한다.
세마포어 값이 음수가 될 수 있나?
POSIX 구현에서는 0 미만으로 내려가지 않고 대기한다(구현에 따라 대기자 수를 음수로 표현하는 이론 모델도 있음).